Skip to content

Add vector_index virtual table - #3

Merged
justintout merged 3 commits into
feature/faster-distancefrom
feature/vector-vtab
Sep 14, 2026
Merged

justintout merged 3 commits into
feature/faster-distancefrom
feature/vector-vtab

Conversation

@justintout

@justintout justintout commented Sep 14, 2026 •

Copy link
Copy Markdown
Owner

Stacked on #2. Review that first; this PR's diff is only the commits below.

Summary

  • Bump zombiezen.com/go/sqlite to v1.4.3-0.20260620165750-ca2041ed3dd2. This picks up Fix variable shadowing in resultError that silently swallows function errors zombiezen/go-sqlite#133, which fixes errors returned from SQL functions being silently dropped. The fix is merged but not yet tagged. Since v1.4.2 there are six commits: the fix, one docs change and four CI updates. The modernc versions are unchanged. This commit un-skips the 12 error tests blocked on the bug and removes an obsolete test that expected placeholder functions to error.
  • Add the vector_index virtual table. It stores vectors in chunks of about 1 MiB inside shadow tables and answers k-nearest neighbor queries by scanning whole chunks in Go on GOMAXPROCS goroutines. It avoids a SQL function call for every row.

Usage

CREATE VIRTUAL TABLE docs_vec USING vector_index();    -- float32
CREATE VIRTUAL TABLE docs_q USING vector_index(int8);  -- requires WithQuantRange
CREATE VIRTUAL TABLE docs_c USING vector_index(float32, chunk_size=512);

INSERT INTO docs_vec (rowid, embedding) VALUES (?1, ?2);

SELECT rowid, distance FROM docs_vec WHERE embedding MATCH ?1 AND k = 10;
SELECT rowid, distance FROM docs_vec WHERE embedding MATCH ?1 ORDER BY distance LIMIT 10;
  • k can be given as k = ? or as LIMIT, optionally with OFFSET.
  • SQLite does not pass LIMIT through joins, so a join must use k or a subquery. A join that relies on LIMIT alone returns an error saying so.
  • INSERT, UPDATE and DELETE are supported, including rowid changes and inserts without a rowid.
  • Rename and drop move or remove the shadow tables.
  • Reopening with a different dimension or quantization range than the table was created with is an error.

Chunk size

  • The default is max(1, 1 MiB / vectorBytes) vectors per chunk. chunk_size=N overrides it. Both are stored when the table is created.
  • A sweep over 128, 768 and 1536 dimensions compared chunk data sizes of 64 KiB, 256 KiB, 1 MiB and 4 MiB, for float32 and int8. Search was fastest near 1 MiB. Inserts were 4.5-16x slower at 4 MiB than at 1 MiB.
  • The first version of this PR used a fixed 1024 vectors per chunk. That is 3 MiB at 768 dimensions and 6 MiB at 1536, which falls in the slow-insert range.
  • The README has the full trade-off table.

Storage

  • name_info: dimension, storage type, quantization range and chunk size.
  • name_chunks: chunk id and occupied size. It is kept separate from the data because updating a row rewrites its blobs. The first version kept them together and took 5m44s to insert SIFT1M.
  • name_data: one blob of rowids and one blob of vectors per chunk.
  • name_rowids: maps each rowid to its chunk and slot.

A delete moves the chunk's last vector into the freed slot, so chunks never have holes.

Results

SIFT1M: 1M vectors × 128 dimensions, k=100, Apple M3 Max, p50.

Default settings mmap_size 4 GiB Insert 1M
vector_distance 385 ms 248 ms 2.6 s
vector_distance_q 347 ms 287 ms +3.3 s
vector_index 145 ms 64 ms 13.6 s
vector_index(int8) 39 ms 17 ms 13.6 s
sqlite-vec vec0 (reference) 143 ms not measured 4.4 s
  • Recall matches the scalar functions.
  • vector_index searches use all cores. The other rows are single-threaded.
  • With mmap, most of the remaining search time is spent reading chunk blobs through modernc.

Known limitations

  • At 128 dimensions, inserts are about 5x slower than into a plain table: each insert runs several statements and opens two blob handles. At 768 and 1536 dimensions, float32 inserts match a plain table, and int8 inserts are faster.
  • At 128 dimensions, the old 1024-vector chunks (512 KiB) inserted SIFT1M in 9.8 s with the same search speed. 512 KiB was not included in the sweep.
  • Reading the embedding column costs one lookup per row. Searches that return only rowid and distance do not pay this.
  • The first search after a bulk load is slower, which inflates p99.

Testing

  • go vet ./... and go vet -tags sift ./...
  • go test -race ./...
  • vector_index_test.go compares search results and distances against vector_distance / vector_distance_q brute force, for float32 and int8, across more than two chunks. It covers k, LIMIT, OFFSET, joins, deletes, updates, rowid changes, rollback, stored-embedding round trips, error cases (checking that failed statements leave rows intact), reopening, rename and drop, and default and explicit chunk sizes.
  • go test -tags sift -run TestSIFT1M -timeout 0 -v -sift.dir .tmp/sift

🤖 Generated with Claude Code

https://claude.ai/code/session_01PTvMD95uUfGT1jasgFsLdP

justintout and others added 3 commits September 13, 2026 20:44
v1.4.2 shadowed err in resultError, so errors returned from SQL functions
were silently dropped. The fix (zombiezen/go-sqlite#133) is merged but not
yet tagged, so this pins the main pseudo-version. Un-skips the error tests
that were blocked on it and removes the obsolete stub-error test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PTvMD95uUfGT1jasgFsLdP
vector_index stores vectors in chunks of 1024 inside shadow tables and
answers k-nearest neighbor queries by reading whole chunks and scanning
them in Go across GOMAXPROCS goroutines, instead of evaluating a SQL
function per row. Supports float32 and int8 storage, k = ? and LIMIT
(with OFFSET), rowid lookups, full scans, INSERT/UPDATE/DELETE, rename,
and drop. Reconnecting with a different dimension or quantization range
is an error.

SIFT1M p50 (k=100, default settings): 139 ms float32 and 46 ms int8,
versus 363 ms and 336 ms for the scalar functions. Inserting 1M vectors
takes 9.8 s (float32) and 6.5 s (int8).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PTvMD95uUfGT1jasgFsLdP
Chunks held a fixed 1024 vectors, which is 512 KiB at 128 dimensions but
6 MiB at 1536. A sweep over 128, 768, and 1536 dimensions and chunk data
sizes from 64 KiB to 4 MiB found search fastest near 1 MiB and inserts
4.5-16x slower at 4 MiB than at 1 MiB, for both float32 and int8.

The default capacity is now max(1, 1 MiB / vector bytes). A chunk_size=N
argument overrides it. Both are stored in name_info at creation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PTvMD95uUfGT1jasgFsLdP
@justintout
justintout merged commit d2d496f into feature/faster-distance Sep 14, 2026
1 check passed
justintout added a commit that referenced this pull request Sep 14, 2026
* add SIFT1M benchmark and comparison

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PTvMD95uUfGT1jasgFsLdP

* label int8 SIFT1M row as quantized scan

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PTvMD95uUfGT1jasgFsLdP

* compute distances directly on blobs

vector_distance decoded both arguments into new []float32 slices for every
row scanned. It now decodes values inside an unrolled distance loop with no
allocations. vector_distance_q computes the distance from int8 differences
and scales once, since dequantization is affine; dequantize is removed.

On SIFT1M (1M x 128, k=100, single thread) p50 query latency drops from
733 ms to 362 ms for float32 and from 659 ms to 339 ms for int8, with
unchanged recall. README adds a PRAGMA mmap_size tip (246 ms float32).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PTvMD95uUfGT1jasgFsLdP

* remove PLAN.md

The implementation plan is complete. CLAUDE.md drops the rules that
depended on it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PTvMD95uUfGT1jasgFsLdP

* Add vector_index virtual table (#3)

* bump zombiezen.com/go/sqlite for function error fix

v1.4.2 shadowed err in resultError, so errors returned from SQL functions
were silently dropped. The fix (zombiezen/go-sqlite#133) is merged but not
yet tagged, so this pins the main pseudo-version. Un-skips the error tests
that were blocked on it and removes the obsolete stub-error test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PTvMD95uUfGT1jasgFsLdP

* add vector_index virtual table

vector_index stores vectors in chunks of 1024 inside shadow tables and
answers k-nearest neighbor queries by reading whole chunks and scanning
them in Go across GOMAXPROCS goroutines, instead of evaluating a SQL
function per row. Supports float32 and int8 storage, k = ? and LIMIT
(with OFFSET), rowid lookups, full scans, INSERT/UPDATE/DELETE, rename,
and drop. Reconnecting with a different dimension or quantization range
is an error.

SIFT1M p50 (k=100, default settings): 139 ms float32 and 46 ms int8,
versus 363 ms and 336 ms for the scalar functions. Inserting 1M vectors
takes 9.8 s (float32) and 6.5 s (int8).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PTvMD95uUfGT1jasgFsLdP

* size vector_index chunks by bytes and add chunk_size

Chunks held a fixed 1024 vectors, which is 512 KiB at 128 dimensions but
6 MiB at 1536. A sweep over 128, 768, and 1536 dimensions and chunk data
sizes from 64 KiB to 4 MiB found search fastest near 1 MiB and inserts
4.5-16x slower at 4 MiB than at 1 MiB, for both float32 and int8.

The default capacity is now max(1, 1 MiB / vector bytes). A chunk_size=N
argument overrides it. Both are stored in name_info at creation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PTvMD95uUfGT1jasgFsLdP

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant